/*******************************************************************************
 * Copyright 2011-2012 Dik Grapendaal
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *   http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 ******************************************************************************/
package sh.grapendaal.tsuushin.module;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.inject.Inject;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import sh.grapendaal.tsuushin.core.Context;
import sh.grapendaal.tsuushin.core.RunnableCommand;
import sh.grapendaal.tsuushin.core.TsuushinException;
import sh.grapendaal.tsuushin.message.IncomingMessage;
import sh.grapendaal.tsuushin.message.MessageType;
import sh.grapendaal.tsuushin.service.CommandService;
import sh.grapendaal.tsuushin.service.PermissionService;
import sh.grapendaal.tsuushin.service.Service;

public class ModuleContainerImpl implements ModuleContainer {
	private static final Logger LOGGER = LoggerFactory.getLogger(ModuleContainerImpl.class);

	@Inject
	private ModuleServiceProxyFactory moduleServiceProxyFactory;

	private Class<?> mainClass;
	private Object instance;
	private Method startMethod;
	private Method stopMethod;
	private Map<Class<? extends Service>, Service> serviceProxies = new HashMap<Class<? extends Service>, Service>();
	private List<Field> serviceWires = new ArrayList<Field>();
	private Map<MessageType, List<Method>> callbacks = new HashMap<MessageType, List<Method>>();
	private List<String> commands = new ArrayList<String>();

	@Override
	public Class<?> getMainClass() {
		return this.mainClass;
	}

	@Override
	public void setMainClass(Class<?> mainClass) {
		this.mainClass = mainClass;
	}

	@Override
	public Method getStartMethod() {
		return this.startMethod;
	}

	@Override
	public void setStartMethod(Method startMethod) {
		this.startMethod = startMethod;
	}

	@Override
	public Method getStopMethod() {
		return this.stopMethod;
	}

	@Override
	public void setStopMethod(Method stopMethod) {
		this.stopMethod = stopMethod;
	}

	@Override
	public void instantiate() {
		if (this.mainClass == null) {
			throw new TsuushinException("No main class configured.");
		}

		try {
			this.instance = this.mainClass.newInstance();

			injectServices();

			if (this.startMethod != null) {
				invokeMethod(this.startMethod, null);
			}
		} catch (InstantiationException e) {
			throw new TsuushinException(e);
		} catch (IllegalAccessException e) {
			throw new TsuushinException(e);
		}
	}

	@Override
	public void destroy() {
		if (this.stopMethod != null) {
			invokeMethod(this.stopMethod, null);
		}

		uninjectServices();
		unregisterCommands();
		unregisterPermissions();

		this.serviceWires.clear();
		this.callbacks.clear();
		this.instance = null;
	}

	@Override
	public void registerPermission(String permission) {
		PermissionService permissionService = getService(PermissionService.class);
		permissionService.registerPermission(permission);
	}

	@Override
	public void registerServiceWire(Field field) {
		this.serviceWires.add(field);
	}

	@Override
	public void registerCallback(Method method, MessageType messageType) {
		if (!this.callbacks.containsKey(messageType)) {
			this.callbacks.put(messageType, new ArrayList<Method>());
		}

		this.callbacks.get(messageType).add(method);
	}

	@Override
	public void registerCommand(final Method method, String trigger, String permission) {
		CommandService commandService = getService(CommandService.class);
		try {
			commandService.registerCommand(trigger, permission, new RunnableCommand() {
				@Override
				public boolean run(Context context, IncomingMessage message) {
					invokeMethodDynamic(method, new Object[] { context, message });
					return true;
				}
			});
			this.commands.add(trigger);
		} catch (IllegalArgumentException e) {
			LOGGER.error("Could not register command " + trigger, e);
		}
	}

	@Override
	public void dispatchCallbacks(Context context, IncomingMessage message) {
		List<Method> methods = this.callbacks.get(message.getMessageType());
		Object[] availableParameters = new Object[] { context, message };
		if (methods != null) {
			for (Method method : methods) {
				invokeMethodDynamic(method, availableParameters);
			}
		}
	}

	/**
	 * Convenience method to assign a value to the given field in the main
	 * class. This will silently fail if the assignment fails.
	 * 
	 * @param field
	 *            The field to assign the value to.
	 * @param value
	 *            The value to assign.
	 */
	private void setField(Field field, Object value) {
		try {
			boolean accessible = field.isAccessible();
			field.setAccessible(true);

			field.set(this.instance, value);

			field.setAccessible(accessible);
		} catch (IllegalArgumentException e) {
			LOGGER.error("Illegal argument exception while setting {}", field.getName(), e);
		} catch (IllegalAccessException e) {
			LOGGER.error("Illegal access exception while setting {}", field.getName(), e);
		}
	}

	/**
	 * Invokes the given method after determining what parameters the method
	 * needs and in what order. All possible parameters are given using the
	 * availableParameters parameter.
	 * 
	 * @param method
	 *            The method to invoke.
	 * @param availableParameters
	 *            The parameter values we have to give.
	 */
	private void invokeMethodDynamic(Method method, Object[] availableParameters) {
		Object[] parameters = determineParameters(method, availableParameters);
		invokeMethod(method, parameters);
	}

	/**
	 * Determines the array of parameters to pass to the given method call,
	 * given the array of available parameter values.
	 * 
	 * @param method
	 *            The method we want to call.
	 * @param availableParameters
	 *            The parameter values we have available.
	 * @return The correct array of parameters to the method with.
	 */
	private Object[] determineParameters(Method method, Object[] availableParameters) {
		Class<?>[] parameterTypes = method.getParameterTypes();
		Object[] parameters = new Object[parameterTypes.length];
		for (int i = 0; i < parameterTypes.length; i++) {
			parameters[i] = determineParameter(parameterTypes[i], availableParameters);
		}
		return parameters;
	}

	/**
	 * Determines and returns the first matching object in the given
	 * availableObjects array which is of the given type.
	 * 
	 * @param neededType
	 *            The type to look for.
	 * @param availableObjects
	 *            The available objects we can choose from.
	 * @return The first object in the array matching the type.
	 */
	private Object determineParameter(Class<?> neededType, Object[] availableObjects) {
		for (Object object : availableObjects) {
			if (neededType.isInstance(object)) {
				return object;
			}
		}
		LOGGER.warn("Could not determine parameter for {}, available: {}", neededType, availableObjects);
		return null;
	}

	/**
	 * Convenience method to invoke a method in the main class. This will not
	 * throw an Exception if the invocation fails, but log an error instead.
	 * 
	 * @param method
	 *            The method to invoke.
	 * @param parameters
	 *            The parameters to pass to the method.
	 */
	private void invokeMethod(Method method, Object[] parameters) {
		try {
			method.invoke(this.instance, (parameters == null ? new Object[] {} : parameters));
		} catch (IllegalArgumentException e) {
			LOGGER.error("Illegal argument exception while invoking {}", method.getName(), e);
		} catch (IllegalAccessException e) {
			LOGGER.error("Illegal access exception while invoking {}", method.getName(), e);
		} catch (InvocationTargetException e) {
			LOGGER.error("Invocation target exception while invoking {}", method.getName(), e);
		}
	}

	/**
	 * This method will inject all found fields with the needed services.
	 */
	@SuppressWarnings("unchecked")
	private void injectServices() {
		for (Field field : this.serviceWires) {
			try {
				Class<? extends Service> type = (Class<Service>) field.getType();
				setField(field, getService(type));
			} catch (Exception e) {
				LOGGER.error("Exception occurred while trying to wire service of type {}.", field.getType(), e);
			}
		}
	}

	/**
	 * This method removes the injected services.
	 */
	private void uninjectServices() {
		for (Field field : this.serviceWires) {
			setField(field, null);
		}
	}

	/**
	 * This method unregisters all commands registered by this module from the
	 * Command Service.
	 */
	private void unregisterCommands() {
		CommandService commandService = getService(CommandService.class);
		for (String trigger : this.commands) {
			commandService.unregisterCommand(trigger);
		}
	}

	/**
	 * This method unregisters all permissions registered by this module.
	 */
	private void unregisterPermissions() {
		PermissionService permissionService = getService(PermissionService.class);
		for (String permission : permissionService.getUsablePermissions()) {
			permissionService.unregisterPermission(permission);
		}
	}

	/**
	 * Returns a reference to the service of the given type. If this module
	 * already has a reference to a service proxy, it will be returned,
	 * otherwise a new one is created through the
	 * {@link ModuleServiceProxyFactory}.
	 * 
	 * @param type
	 *            The type of service we're looking for.
	 * @return The reference to the service (proxy) for this specific module.
	 */
	@SuppressWarnings("unchecked")
	private <T extends Service> T getService(Class<T> type) {
		if (this.serviceProxies.containsKey(type)) {
			return (T) this.serviceProxies.get(type);
		}

		T proxy = this.moduleServiceProxyFactory.create(type);

		this.serviceProxies.put(type, proxy);

		return proxy;
	}
}
